Skip to content

Avoid no-op EC KV reads in post-send pull sync - #900

Open
ChristianPavilonis wants to merge 1 commit into
fix/kv-eid-request-snapshot-ec-ttlfrom
fix/no-op-kv-reads
Open

Avoid no-op EC KV reads in post-send pull sync#900
ChristianPavilonis wants to merge 1 commit into
fix/kv-eid-request-snapshot-ec-ttlfrom
fix/no-op-kv-reads

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Skip post-send identity-graph construction when pull sync has no eligible work.
  • Add a signed, one-hour browser completeness marker bound to the active EC ID and current pull-partner set.
  • Preserve shared snapshot reads required by auctions, EID ingestion, withdrawal, and orphan recovery while safely falling back for stale or invalid markers.

This PR is stacked on #885. The zero-operation guarantee is scoped to KV work caused solely by pull sync; other identity lifecycle consumers may still require the shared EC snapshot.

Changes

File Change
crates/trusted-server-core/src/ec/pull_sync_marker.rs Add the signed marker protocol, partner-set fingerprinting, cookie lifecycle, and focused tests
crates/trusted-server-core/src/ec/{mod,finalize,pull_sync}.rs Carry marker state, reconcile it before response delivery, and prepare no-op pull paths before graph construction
crates/trusted-server-core/src/publisher.rs Validate markers before snapshot preload while preserving reads required by other identity consumers
crates/trusted-server-adapter-fastly/src/main.rs Construct the post-send identity graph only after pull-sync preparation succeeds
docs/guide/edge-cookies.md Document the host-only marker and its bounded behavior
docs/superpowers/plans/2026-07-13-issue-880-no-op-pull-sync-kv-reads.md Record the reviewed implementation plan and validation contract

Closes

Closes #880

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve — not run
  • Other: cargo test-cloudflare, cargo test-spin, cross-adapter parity, and all Cloudflare/Spin native + WASM clippy targets

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses log macros (not println!)
  • New code has tests
  • No secrets or credentials committed

@ChristianPavilonis ChristianPavilonis added this to the 202609 milestone Sep 3, 2026
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review September 3, 2026 17:06

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Solid, well-scoped implementation of the #880 contract: the marker protocol is cryptographically careful, the preload gate enumerates its consumers explicitly instead of guessing, and the fallbacks all fail toward the KV path. One blocking issue: the withdrawal path emits the marker-expiry Set-Cookie unconditionally, which downgrades cache privacy for an entire traffic class that previously had no Set-Cookie at all.

2 of the inline comments below carry a one-click GitHub suggestion — use Commit suggestion (or Add suggestion to batch) to apply them as commits on the PR branch. Both were applied in an isolated worktree at this head and verified with cargo fmt --all -- --check, cargo clippy-fastly, cargo check-axum, cargo check-cloudflare, and the native trusted-server-core ec:: + publisher marker tests (332 and 28 passing respectively), with a byte-exact pre/post drift check. The remaining comments describe the fix in prose because it spans two files.

Blocking

🔧 wrench

  • Withdrawal always emits a marker-expiry Set-Cookie, downgrading cache privacy for all withdrawn traffic — see inline at crates/trusted-server-core/src/ec/finalize.rs:63

Non-blocking

🤔 thinking

  • Marker issuance re-adds a Set-Cookie to responses PR #885 deliberately made cookie-free — see inline at crates/trusted-server-core/src/ec/finalize.rs:160

♻️ refactor

  • Completeness duplicates dispatch eligibility; the two can drift — see inline at crates/trusted-server-core/src/ec/pull_sync_marker.rs:170

⛏ nitpick

  • The three-state loop asserts the same thing three times — see inline at crates/trusted-server-adapter-fastly/src/main.rs:553

👍 praise

  • Marker crypto and framing — see inline at crates/trusted-server-core/src/ec/pull_sync_marker.rs:299

Cross-cutting / body-level findings

  • 📝 Verified as correct, for the record — several things that look risky on first read hold up: the expect in reconcile_marker is unreachable because the belongs_to check precedes the Present match and entry_for ignores generation; the partner-set fingerprint is consistent across the validate and issue sites because every caller builds the registry from PartnerRegistry::from_config(&settings.ec.partners); upsert_partner_ids_from_snapshot returns early on empty updates, so a marker-valid navigation really does perform zero reads rather than moving the read later; stored KV EIDs reach only the auction path, which auction_needs_row forces the read for; tombstones are excluded via consent.ok; and per-partner pull_sync_ttl_sec is not wired into eligibility, so presence-only completeness matches dispatch today (see the ♻️ finding for the drift risk that creates).

CI Status

  • cargo fmt: PASS
  • cargo test: PASS
  • cargo test (axum native): PASS
  • cargo test (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native): PASS
  • vitest: PASS
  • format-typescript: PASS
  • format-docs: PASS
  • prepare integration artifacts: PASS
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • browser integration tests: PASS

All 14 reported checks pass. gh pr checks --required returned no names for this PR, so no check is annotated as branch-protection-required (the base branch is fix/kv-eid-request-snapshot-ec-ttl, not main).

Comment on lines +63 to +65
if consent_withdrawn {
expire_marker(ec_context.pull_sync_marker_mut(), response);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Withdrawal always emits a marker-expiry Set-Cookie, downgrading cache privacy for all withdrawn traffic.

expire_marker has no presence guard — it appends the cookie whatever the state, including Absent. So every explicitly-withdrawn request (GPC=true, US-state opt-out) now returns Set-Cookie: ts-ec-pull-complete=; …Max-Age=0, even for the common case of a withdrawn visitor who has no EC cookie and never had a marker. On the base branch this path emitted no Set-Cookie at all, because expire_ec_cookie is gated on cookie_was_present().

Downstream that matters: apply_terminal_response_effects (crates/trusted-server-adapter-fastly/src/main.rs:418) runs enforce_set_cookie_cache_privacy after finalize, and for any cookie-bearing response that strips every edge-cache header and forces Cache-Control: private, max-age=0. template_cache_ttl (crates/trusted-server-core/src/publisher.rs:6168) also bypasses the shared template cache on Set-Cookie. Withdrawn requests never run the ad stack, so they never get with_cache_bypass — those responses were shareable before this change and are not after it, for an entire traffic class, as a side effect of a KV-read optimization.

Gating on was_present() keeps the plan's guarantee intact: it reads only the request cookie, never KV, so withdrawal still clears the marker with no KV dependency — exactly how ts-ec expiry is already gated on cookie_was_present(). explicit_withdrawal_expires_marker_without_ec_cookie still passes, since it seeds PullSyncMarkerState::Invalid (present).

Suggested change
if consent_withdrawn {
expire_marker(ec_context.pull_sync_marker_mut(), response);
}
if consent_withdrawn && ec_context.pull_sync_marker().was_present() {
expire_marker(ec_context.pull_sync_marker_mut(), response);
}

Verified in an isolated worktree at this head: cargo fmt --all -- --check, cargo clippy-fastly, cargo check-axum, cargo check-cloudflare, and 332 native trusted-server-core ec:: tests all pass with this applied.

reconcile_pull_sync_marker(settings, registry, ec_context, response);
}

fn reconcile_pull_sync_marker(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — Marker issuance re-adds a Set-Cookie to responses PR #885 deliberately made cookie-free.

Same mechanism as the wrench finding above, but inherent to the design rather than a bug. The returning-user path comments that "ordinary returning-user page views no longer refresh the browser cookie" — yet a marker issue or expire makes exactly those responses cookie-bearing, so enforce_set_cookie_cache_privacy downgrades them to private, max-age=0 with edge-cache headers stripped, and Esi-mode responses stop being eligible for template-cache storage.

Bounded to roughly one response per hour per user per host, against one saved KV read per navigation, so it is plausibly a clear win — but the trade is not in the plan's risk table or in docs/guide/edge-cookies.md, and the two effects land in different budgets (KV operations vs. edge cache hit rate). Worth stating explicitly so it isn't rediscovered from a cache-hit-rate graph later.

Comment on lines +170 to +175
fn entry_has_all_pull_partner_ids(entry: &KvEntry, pull_partners: &[String]) -> bool {
entry.consent.ok
&& pull_partners
.iter()
.all(|source_domain| entry.ids.contains_key(source_domain))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — Completeness duplicates dispatch eligibility; the two can drift.

entry_has_all_pull_partner_ids reimplements the inverse of is_partner_pull_eligible (crates/trusted-server-core/src/ec/pull_sync.rs:286-290, which is just entry.ids.get(&partner.source_domain).is_none()). They agree today only because eligibility ignores pull_sync_ttl_sec. If per-partner TTL refresh is ever wired into eligibility, completeness silently over-claims, and the marker then suppresses both the snapshot read and pull sync for up to an hour — the failure would be invisible, because both sides would still "agree" in their own tests.

Deriving one from the other makes that drift impossible:

// pull_sync.rs
pub(crate) fn is_partner_pull_eligible(partner: &PartnerConfig, kv_entry: Option<&KvEntry>) -> bool

// pull_sync_marker.rs
fn entry_has_all_pull_partner_ids(entry: &KvEntry, registry: &PartnerRegistry) -> bool {
    entry.consent.ok
        && !registry
            .pull_enabled_partners()
            .iter()
            .any(|partner| is_partner_pull_eligible(partner, Some(entry)))
}

Apply manually — can't be auto-applied as a suggestion because it changes is_partner_pull_eligible's visibility in pull_sync.rs and replaces the domain-slice parameter threaded through reconcile_marker, so it spans two files.

Comment on lines +553 to +571
#[test]
fn pull_sync_noop_states_skip_post_send_graph_factory() {
for reason in ["no partners", "complete snapshot", "unread marker state"] {
let calls = std::cell::Cell::new(0);
let result = prepare_pull_sync_after_send(None, || {
calls.set(calls.get() + 1);
Err(Report::new(TrustedServerError::KvStore {
store_name: "unexpected".to_owned(),
message: "graph factory should not run".to_owned(),
}))
});
assert!(result.is_none(), "{reason} preparation should return none");
assert_eq!(
calls.get(),
0,
"{reason} should not invoke the graph factory"
);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — The three-state loop asserts the same thing three times.

for reason in ["no partners", "complete snapshot", "unread marker state"] runs three byte-identical prepare_pull_sync_after_send(None, …) calls. None of the three named states is exercised here — all three are decided inside build_pull_sync_context, which this test never calls — so the labels read as coverage that doesn't exist. What the test actually proves (a None plan never reaches the graph factory) is worth keeping; the loop is not.

Suggested change
#[test]
fn pull_sync_noop_states_skip_post_send_graph_factory() {
for reason in ["no partners", "complete snapshot", "unread marker state"] {
let calls = std::cell::Cell::new(0);
let result = prepare_pull_sync_after_send(None, || {
calls.set(calls.get() + 1);
Err(Report::new(TrustedServerError::KvStore {
store_name: "unexpected".to_owned(),
message: "graph factory should not run".to_owned(),
}))
});
assert!(result.is_none(), "{reason} preparation should return none");
assert_eq!(
calls.get(),
0,
"{reason} should not invoke the graph factory"
);
}
}
#[test]
fn pull_sync_noop_states_skip_post_send_graph_factory() {
let calls = std::cell::Cell::new(0);
let result = prepare_pull_sync_after_send(None, || {
calls.set(calls.get() + 1);
Err(Report::new(TrustedServerError::KvStore {
store_name: "unexpected".to_owned(),
message: "graph factory should not run".to_owned(),
}))
});
assert!(
result.is_none(),
"a skipped pull-sync plan should return none"
);
assert_eq!(calls.get(), 0, "should not invoke the graph factory");
}

The three states themselves are already covered in core by build_pull_sync_context_skips_empty_registry_and_complete_snapshot.

Verified in an isolated worktree at this head: cargo fmt --all -- --check and cargo clippy-fastly (which compiles --all-targets, including this test) both pass with this applied.

Some(expires_at)
}

fn marker_key(settings: &Settings) -> [u8; 32] {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 praise — Marker crypto and framing.

The details here are the ones that usually get skipped: labelled key derivation off ec.passphrase rather than using the passphrase directly as the MAC key; a NUL-delimited payload that puts the version and the fingerprint inside the MAC input, so no field can be shifted into another; constant-time verify_slice; a length cap applied before any parsing; a host-only cookie, deliberately tighter than ts-ec's Domain=.<publisher>; and Redacted plus a hand-written Debug so the value can't leak through a log line. No UID or EC ID in the cookie itself. The order-independent, set-sensitive fingerprint test covering add/remove/enable/disable is the right shape of test for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants